跳到主要内容

生产实践与 CICD

前面六篇解决了「写得出来、管得住 state」,这篇解决「团队怎么一起用」:规范检查怎么进流水线、变更怎么走 PR 评审、CI 里怎么安全地跑 apply、版本怎么锁。一个人玩 Terraform 怎么顺手怎么来,团队玩就必须有规矩——这篇就是定规矩的。示例基于 Terraform 1.9+,CI 以 GitLab CI 为例,GitHub Actions、Jenkins 思路一致。假设 state 已按 State 状态管理 一篇配置为远程后端。

代码规范流水线​

Terraform 代码的规范检查分四层,各管一段,建议全部进 CI:

工具定位能查出的问题
terraform fmt官方格式化缩进、对齐、换行不统一
terraform validate官方语法校验HCL 语法错误、引用了不存在的变量或资源
tflint第三方静态检查声明了没用的变量、云厂商特有错误(实例类型不存在、废弃写法)
checkov安全合规扫描安全组放开 0.0.0.0/0、存储桶未加密、缺日志审计

逐个说接入姿势。

fmt 用 -check 模式进 CI。本地写代码随手 terraform fmt -recursive 自动修好,CI 里只检查不修改,有文件不合规就挂掉流水线:

# 本地:一键格式化整个项目
terraform fmt -recursive

# CI:只检查,不合规退出码非 0
terraform fmt -check -recursive

validate 需要先 init,但不连云平台。用 -backend=false 跳过后端初始化,CI 里不配云凭据也能跑:

terraform init -backend=false
terraform validate

tflint 按云厂商装插件。项目根目录放一份 .tflint.hcl:

plugin "aws" {
enabled = true
version = "0.32.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
tflint --init   # 首次安装插件
tflint # 日常检查

它能查出 validate 查不出的东西,比如 instance_type = "t3.xlarg"(拼错一个字母,AWS 上根本没这型号)、声明了但全文没引用的 variable。

checkov 扫安全底线。它不懂你的业务,但能守住「别把数据库暴露给公网」这类低级红线:

pip install checkov
checkov -d . --framework terraform
本地先过一遍再推

把四个命令包成一个 make check 或 pre-commit hook,提交前本地跑完,CI 基本不会红。CI 红了再回头修,一来一回比本地跑十秒贵得多。

PR 工作流​

推荐的变更流程三步走:

  1. 开 PR,CI 自动跑 plan,把结果贴回 PR。评审的人不用拉代码、不用本地跑,直接在 PR 里看「这次要建 2 个、改 1 个、删 0 个」。
  2. 评审。评审的核心不是代码风格,是 plan 输出——代码写得再漂亮,plan 里冒出意料外的 destroy 就不能合。
  3. 合并后 apply。合并进 main 分支才允许 apply,且 apply 跑的必须是合并后的 main,保证「评审过的 plan」和「实际执行的变更」是同一份。

为什么 plan 结果一定要进 PR 评论?因为 HCL 是声明式的,代码 diff 看不出真实影响:count 改一个数字,diff 只有一行,实际可能是删掉十台机器。plan 输出才是变更的真实面目,评审必须对着它评。

plan 和 apply 之间别隔太久

plan 是基于当时的 state 和世界状态算出来的。PR 挂了一周才合并,这一周里别人动过 state、控制台上有人改了资源,旧 plan 就失真了。规矩:apply 前用合并后的代码重新出 plan,隔夜的 plan 文件直接丢弃。

CI 集成示例​

下面是一份可以直接抄的 .gitlab-ci.yml,三段式:validate → plan → apply。apply 手动触发,且只允许在 main 分支上跑:

stages:
- validate
- plan
- apply

variables:
# 非交互模式:Terraform 缺输入时直接报错,而不是卡住等输入
TF_INPUT: "false"
# 告诉 Terraform 运行在自动化环境,日志输出更克制
TF_IN_AUTOMATION: "true"
TF_VERSION: "1.9.8"
# Terraform 代码所在目录,按项目实际结构改
TF_ROOT: "environments/prod"

default:
image: hashicorp/terraform:${TF_VERSION}
before_script:
- cd ${TF_ROOT}
- terraform version

fmt:
stage: validate
script:
- terraform fmt -check -recursive ${CI_PROJECT_DIR}

validate:
stage: validate
script:
# 不连后端,validate 不需要云凭据
- terraform init -backend=false
- terraform validate

tflint:
stage: validate
image: ghcr.io/terraform-linters/tflint:latest
script:
- tflint --init
- tflint
allow_failure: true # 刚接入时先告警不卡流程,稳定后改成 false

plan:
stage: plan
script:
- terraform init
- terraform plan -no-color -out=plan.tfplan
artifacts:
paths:
- ${TF_ROOT}/plan.tfplan
expire_in: 1 day
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

apply:
stage: apply
script:
- terraform init
- terraform apply -input=false plan.tfplan
dependencies:
- plan
rules:
# 只在 main 分支出现,且必须手动点击触发
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
environment:
name: production

几个关键点:

  • TF_INPUT=false 必须设。CI 里没有终端,Terraform 一旦尝试交互式提问(比如缺变量)就会无限期挂住,直到流水线超时。设了之后它会立刻报错退出,日志里能看到缺什么。
  • 认证用 CI 变量注入,不落盘。AWS 的话在 GitLab 项目 Settings → CI/CD → Variables 里加 AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY,勾上 Masked(凭据只该给保护分支用的话再勾 Protected)。state 后端凭据同理,全部走环境变量,.tf 文件和 CI 配置里一个明文密码都不写。
  • plan 产物用 artifact 传给 apply,保证 apply 跑的就是评审过的那份 plan;expire_in: 1 day 让隔夜 plan 自动失效,配合上一节「别用隔夜 plan」的规矩。
  • apply 手动触发 + 限定 main 分支,防止 feature 分支误 apply,也给人工确认留了口子。
  • 想把 plan 输出自动评论到 MR 里,用 terraform plan -no-color 的输出配合 GitLab 的 MR 讨论 API 写个小脚本即可;嫌麻烦就直接看 plan job 的日志,效果一样。
容器部署场景延伸

如果 Terraform 变更的最终目的是发新版容器,apply 之后接 autodok 按需更新容器 是个轻量方案,CI 里 POST 一下就触发更新,不用引入重型 CD。

Atlantis 简介​

上面的 GitLab CI 流程够用,但「plan 结果评论回 PR、评论一句话就 apply」这些要自己拼脚本。Atlantis 就是专为这个流程造的工具:自托管一个服务,监听 GitLab / GitHub 的 PR 事件,PR 一开自动跑 plan 并把输出贴回评论;想 apply 就在 PR 里评论一句 atlantis apply,它执行完再把结果贴回来。期间它会锁住对应目录,别人对同一目录开 PR 会提示有冲突在途。

适用判断:团队三人以上、Terraform 目录多、变更频繁,自己维护 CI 脚本开始吃力,就上 Atlantis;一两个人、一周变不了几次,前面那份 GitLab CI 足够了,不必多养一个服务。

版本约束​

Terraform 和 provider 都在快速演进,版本不锁等于给未来的自己埋雷。锁定分两处。

required_version 锁 Terraform 本身,required_providers 锁 provider:

terraform {
required_version = "~> 1.9"

required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.70"
}
}
}

~> 的语义是「允许最右边那段往上走」:

写法等价范围含义
~> 1.9>= 1.9, < 2.01.x 内随便升
~> 1.9.0>= 1.9.0, < 1.10.0只允许升 patch
~> 5.70>= 5.70, < 6.0AWS provider 5.x 内随便升

生产环境推荐 ~> x.y.z 这种三段写法:patch 升级(纯 bug 修复)自动跟上,minor 升级(可能带新行为)手动改版本号、跑过 plan 确认再上。

.terraform.lock.hcl 必须提交 git。terraform init 会生成这个文件,记录每个 provider 的精确版本号和官方校验和。提交它有两个作用:一是团队所有人和 CI 装上一模一样的 provider 版本,杜绝「我本地能跑、CI 挂了」;二是校验和防篡改,init 时发现下载的 provider 和锁文件对不上会直接报错。升级 provider 走显式流程:

terraform init -upgrade
# 检查 .terraform.lock.hcl 的 diff,确认升的是预期版本再提交
git diff .terraform.lock.hcl
lock 文件别手改

.terraform.lock.hcl 里的校验和改一个字符就会导致 init 失败。要换版本就改 required_providers 里的约束再 terraform init -upgrade,让 Terraform 自己重写锁文件。

团队协作守则​

最后这几条是血泪经验,新成员入队先读这段:

  • 远程 state 是一切协作的前提,还没配的去翻 State 状态管理。本地 state 意味着同一时间只有一个人能干活,且 state 文件丢了等于基础设施「失忆」。
  • 禁止在云控制台手改资源。控制台改的东西 Terraform 不知道,下次 plan 会把改动当漂移「纠正」回去——或者更糟,和代码冲突产生意外变更。发现有人手改了,立刻按第 8 篇的漂移处理流程对齐,再决定代码怎么改。
  • apply 前必读 plan 全文。重点看三处:destroy 出现的位置是否符合预期、变更数量级对不对(预期改 2 个结果变了 200 个)、有没有 forces replacement(强制重建,意味着先删后建)。不读 plan 就敲 yes 等于闭眼开车。
  • 生产变更走变更窗口。哪怕 plan 看起来人畜无害,生产 apply 也约在低峰期,窗口内至少两个人在线——一个操作,一个盯监控。
  • 一个目录同一时刻只有一个在途变更。CI 串行执行 + state 锁兜底,别两个人同时对同一目录合并 PR。

小结​

  • 规范检查四层各管一段:fmt 管格式、validate 管语法、tflint 管代码质量、checkov 管安全,本地先跑一遍,CI 再卡一道。
  • PR 工作流核心:plan 输出必须进 PR 评论,评审对着 plan 评;合并到 main 后才允许 apply,apply 用刚产出的 plan 文件。
  • CI 三件套:TF_INPUT=false、凭据走环境变量、apply 手动触发且限定 main;团队大了再上 Atlantis。
  • 版本双锁:required_version 和 required_providers 用 ~> 约束,.terraform.lock.hcl 提交 git。
  • 守则一句话:远程 state、控制台零手改、plan 必读、窗口期变更。

下一篇是本系列收官:出问题怎么办——调试手段、常见报错清单、状态漂移修复,以及一份收束全系列的最佳实践清单。